<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Network Viewer</title>
    <style>
        /* Styles to make the canvas full width and height */
        body, html {
            margin: 0;
            padding: 0;
            height: 100%;
            width: 100%;
            overflow: hidden;
        }

        #d3-canvas {
            height: 100%;
            width: 100%;
        }
    </style>
    <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400&display=swap" rel="stylesheet">
    <!-- Include D3.js -->
    <script src="https://d3js.org/d3.v7.min.js"></script>
</head>
<body>
<div id="d3-canvas"></div> <!-- Div for D3 rendering -->

<script>

    let config = {
        listen: true,
    }

    function transformData(dataPayload) {
        // console.log("dataPayload", dataPayload);
        const desiredColumns = ["Direction", "Protocol", "Namespace", "Process", "LocalIP", "LocalPort", "RemoteIP", "RemotePort", "LocalAddressSpace", "RemoteAddressSpace"];

        const transformedData = [];
        const appMap = new Map();

        console.log(dataPayload);
        dataPayload.data.forEach(row => {
            const rowData = {};
            desiredColumns.forEach(columnName => {
                const columnIndex = dataPayload.columns[columnName].index;
                rowData[columnName] = row[columnIndex];
            });

            const appName = rowData['Process'];
            if (!appMap.has(appName)) {
                appMap.set(appName, {
                    counts: {
                        listen: 0,
                        inbound: 0,
                        outbound: 0,
                        local: 0,
                        private: 0,
                        public: 0,
                        total: 0
                    }
                });
            }

            const appData = appMap.get(appName);

            if(config.listen || rowData['Direction'] !== 'listen')
                appData.counts.total++;

            if (rowData['Direction'] === 'listen')
                appData.counts.listen++;
            else if (rowData['Direction'] === 'local')
                appData.counts.local++;
            else if (rowData['Direction'] === 'inbound')
                appData.counts.inbound++;
            else if (rowData['Direction'] === 'outbound')
                appData.counts.outbound++;

            if (rowData['RemoteAddressSpace'] === 'public')
                appData.counts.public++;
            else if (rowData['RemoteAddressSpace'] === 'private')
                appData.counts.private++;
        });

        // Convert the map to an array format
        for (let [appName, appData] of appMap) {
            transformedData.push({
                name: appName,
                ...appData
            });
        }

        if(!config.listen)
            return transformedData.filter(function(d) {
                return d.counts.total > 0;
            });
        else
            return transformedData;
    }

    function normalizeData(data, w, h, borderPadding) {
        const cw = w / 2 - borderPadding;
        const ch = h / 2 - borderPadding;

        const minSize = 13;
        const maxSize = Math.min(
            (cw * 2) / 3 - borderPadding,
            (ch * 2) / 3 - borderPadding,
            Math.max(5, Math.min(w, h) / data.length) + minSize
        )

        const max = {
            total: d3.max(data, d => d.counts.total),
            local: d3.max(data, d => d.counts.local),
            listen: d3.max(data, d => d.counts.listen),
            private: d3.max(data, d => d.counts.private),
            public: d3.max(data, d => d.counts.public),
            inbound: d3.max(data, d => d.counts.inbound),
            outbound: d3.max(data, d => d.counts.outbound),
        }

        const circleSize = d3.scaleLog()
            .domain([1, max.total])
            .range([minSize, maxSize])
            .clamp(true); // Clamps the output so that it stays within the range

        let listenerDelta = 50;
        let listenersAdded = 0;
        let listenersAddedRight = 0;
        let listenersAddedLeft = 0;
        let listenersH = h - borderPadding;

        data.forEach((d, i) => {
            const logScaleRight = d3.scaleLog().domain([1, d.counts.public + 1]).range([0, cw]);
            const logScaleLeft = d3.scaleLog().domain([1, d.counts.private + 1]).range([0, cw]);
            const logScaleTop = d3.scaleLog().domain([1, d.counts.outbound + 1]).range([0, ch]);
            const logScaleBottom = d3.scaleLog().domain([1, (d.counts.listen + d.counts.inbound) / 2 + 1]).range([0, ch]);

            d.forces = {
                total: d.counts.total / max.total,
                local: d.counts.local / max.local,
                listen: d.counts.listen / max.listen,
                private: d.counts.private / max.private,
                public: d.counts.public / max.public,
                inbound: d.counts.inbound / max.inbound,
                outbound: d.counts.outbound / max.outbound,
            }

            d.pos = {
                // we add 1 to avoid log(0)
                right: logScaleRight(d.counts.public + 1),
                left: logScaleLeft(d.counts.private + 1),
                top: logScaleTop(d.counts.outbound + 1),
                bottom: logScaleBottom(((config.listen ? d.counts.listen : 0) + d.counts.inbound) / (config.listen ? 2 : 1) + 1),
            };

            let x = borderPadding + cw + d.pos.right - d.pos.left;
            let y = borderPadding + ch + d.pos.bottom - d.pos.top;
            let size = circleSize(d.counts.total);

            if(d.counts.listen === d.counts.total) {
                // a listener
                size = circleSize(1);

                if(listenersAdded * listenerDelta > cw * 2 / 3) {
                    // too many listeners on this row
                    // let's start adding on another row above
                    listenersAdded = 0;
                    listenersAddedLeft = 0;
                    listenersAddedRight = 0;
                    listenersH -= 30;
                }

                if(!listenersAdded) {
                    x = cw;
                    y = listenersH - size;
                }
                else {
                    if(listenersAddedLeft >= listenersAddedRight) {
                        listenersAddedRight++;
                        x = cw + listenersAddedRight * listenerDelta;
                        y = listenersH - size - (listenersAddedRight % 2 === 0 ? 0 : 30);
                    }
                    else {
                        listenersAddedLeft++;
                        x = cw - listenersAddedLeft * listenerDelta;
                        y = listenersH - size - (listenersAddedLeft % 2 === 0 ? 0 : 30);
                    }
                }

                listenersAdded++;
            }

            const others = d.counts.total - (d.counts.public + d.counts.private + (config.listen ? d.counts.listen : 0) + d.counts.inbound + d.counts.outbound);
            d.d3 = {
                x: x,
                y: y,
                size: size,
                pie: [
                    { value: d.counts.public },
                    { value: d.counts.private },
                    { value: (config.listen ? d.counts.listen : 0) + d.counts.inbound },
                    { value: d.counts.outbound },
                    { value: others > 0 ? others : 0 },
                ]
            }

            if(d.d3.x - d.d3.size / 2 < borderPadding)
                d.d3.x = borderPadding + d.d3.size * 2;

            if(d.d3.x + d.d3.size / 2 > w)
                d.d3.x = w - d.d3.size * 2;

            if(d.d3.y - d.d3.size / 2 < borderPadding)
                d.d3.y = borderPadding + d.d3.size * 2;

            if(d.d3.y + d.d3.size / 2 > h)
                d.d3.y = h - d.d3.size * 2;

            if (d.name === 'upsd')
                console.log("object", d, "cw", cw, "ch", ch);
        });

        return data;
    }

    const themes = {
        dark: {
            publicColor: "#bb9900",
            privateColor: "#323299",
            serverColor: "#008800",
            clientColor: "#994433",
            otherColor: "#454545",
            backgroundColor: "black",
            appFontColor: "#bbbbbb",
            appFontFamily: 'IBM Plex Sans',
            appFontSize: "12px",
            appFontWeight: "regular",
            borderFontColor: "#aaaaaa",
            borderFontFamily: 'IBM Plex Sans',
            borderFontSize: "14px",
            borderFontWeight: "bold",
        },
        light: {
            publicColor: "#bbaa00",
            privateColor: "#5555ff",
            serverColor: "#009900",
            clientColor: "#990000",
            otherColor: "#666666",
            backgroundColor: "white",
            appFontColor: "black",
            appFontFamily: 'IBM Plex Sans',
            appFontSize: "12px",
            appFontWeight: "bold",
            borderFontColor: "white",
            borderFontFamily: 'IBM Plex Sans',
            borderFontSize: "14px",
            borderFontWeight: "bold",
        }
    }

    function hexToHalfOpacityRGBA(hex) {
        if (hex.length !== 7 || hex[0] !== '#')
            throw new Error('Invalid hex color format');

        const r = parseInt(hex.slice(1, 3), 16);
        const g = parseInt(hex.slice(3, 5), 16);
        const b = parseInt(hex.slice(5, 7), 16);
        return `rgba(${r}, ${g}, ${b}, 0.5)`;
    }

    function getRgbColor(hex, opacity = 1) {
        if (hex.length !== 7 || hex[0] !== "#") throw new Error("Invalid hex color format");

        // Parse the hex color components (red, green, blue)
        const r = parseInt(hex.slice(1, 3), 16);
        const g = parseInt(hex.slice(3, 5), 16);
        const b = parseInt(hex.slice(5, 7), 16);

        // Ensure opacity is within the valid range (0 to 1)
        const validOpacity = Math.min(1, Math.max(0, opacity));

        // Return the RGBA color
        return `rgba(${r}, ${g}, ${b}, ${validOpacity})`;
    }

    function drawInitialChart(svg, data, w, h, borderPadding, theme) {
        const cw = w / 2;
        const ch = h / 2;

        document.body.style.backgroundColor = theme.backgroundColor;

        const clientsGradient = svg.append("defs")
            .append("linearGradient")
            .attr("id", "clientsGradient")
            .attr("x1", "0%")
            .attr("y1", "0%")
            .attr("x2", "0%")
            .attr("y2", "100%");

        clientsGradient.append("stop")
            .attr("offset", "0%")
            .style("stop-color", getRgbColor(theme.clientColor, 1));

        clientsGradient.append("stop")
            .attr("offset", "100%")
            .style("stop-color", getRgbColor(theme.clientColor, 0));

        svg.append("rect")
            .attr("id", "clientsGradientRect")
            .attr("x", 0)
            .attr("y", 0)
            .attr("width", "100%")
            .attr("height", borderPadding / 2)
            .style("fill", "url(#clientsGradient)");

        svg.append("text")
            .attr("id", "clientsText")
            .text("Clients")
            .attr("x", "50%")
            .attr("y", borderPadding / 2 - 4)
            .attr("text-anchor", "middle")
            .style("font-family", theme.borderFontFamily)
            .style("font-size", theme.borderFontSize)
            .style("font-weight", theme.borderFontWeight)
            .style("fill", theme.borderFontColor);

        const serversGradient = svg.append("defs")
            .append("linearGradient")
            .attr("id", "serversGradient")
            .attr("x1", "0%")
            .attr("y1", "100%") // Start from the bottom
            .attr("x2", "0%")
            .attr("y2", "0%") // End at the top

        serversGradient.append("stop")
            .attr("offset", "0%")
            .style("stop-color", getRgbColor(theme.serverColor, 1));

        serversGradient.append("stop")
            .attr("offset", "100%")
            .style("stop-color", getRgbColor(theme.serverColor, 0));

        svg.append("rect")
            .attr("id", "serversGradientRect")
            .attr("x", 0)
            .attr("y", h - borderPadding / 2)
            .attr("width", "100%")
            .attr("height", borderPadding / 2)
            .style("fill", "url(#serversGradient)"); // Use the reversed gradient fill

        svg.append("text")
            .attr("id", "serversText")
            .text("Servers")
            .attr("x", "50%")
            .attr("y", h - borderPadding / 2 + 16)
            .attr("text-anchor", "middle")
            .style("font-family", theme.borderFontFamily)
            .style("font-size", theme.borderFontSize)
            .style("font-weight", theme.borderFontWeight)
            .style("fill", theme.borderFontColor);

        const publicGradient = svg.append("defs")
            .append("linearGradient")
            .attr("id", "publicGradient")
            .attr("x1", "100%") // Start from the right
            .attr("y1", "0%")
            .attr("x2", "0%")   // End at the left
            .attr("y2", "0%");

        publicGradient.append("stop")
            .attr("offset", "0%")
            .style("stop-color", getRgbColor(theme.publicColor, 1));

        publicGradient.append("stop")
            .attr("offset", "100%")
            .style("stop-color", getRgbColor(theme.publicColor, 0));

        svg.append("rect")
            .attr("id", "publicGradientRect")
            .attr("x", w - borderPadding / 2)
            .attr("y", 0)
            .attr("width", borderPadding / 2)
            .attr("height", "100%")
            .style("fill", "url(#publicGradient)");

        svg.append("text")
            .attr("id", "publicText")
            .text("Public")
            .attr("x", w - (borderPadding / 2))
            .attr("y", ch - 10)
            .attr("text-anchor", "middle")
            .attr("dominant-baseline", "middle")
            .attr("transform", `rotate(90, ${w - (borderPadding / 2)}, ${ch})`)
            .style("font-family", theme.borderFontFamily)
            .style("font-size", theme.borderFontSize)
            .style("font-weight", theme.borderFontWeight)
            .style("fill", theme.borderFontColor);

        const privateGradient = svg.append("defs")
            .append("linearGradient")
            .attr("id", "privateGradient")
            .attr("x1", "0%")   // Start from the left
            .attr("y1", "0%")
            .attr("x2", "100%") // End at the right
            .attr("y2", "0%");

        privateGradient.append("stop")
            .attr("offset", "0%")
            .style("stop-color", getRgbColor(theme.privateColor, 1));

        privateGradient.append("stop")
            .attr("offset", "100%")
            .style("stop-color", getRgbColor(theme.privateColor, 0));

        svg.append("rect")
            .attr("id", "privateGradientRect")
            .attr("x", 0)
            .attr("y", 0)
            .attr("width", borderPadding / 2)
            .attr("height", "100%")
            .style("fill", "url(#privateGradient)");

        svg.append("text")
            .attr("id", "privateText")
            .text("Private")
            .attr("x", borderPadding / 2)
            .attr("y", ch)
            .attr("text-anchor", "middle")
            .attr("dominant-baseline", "middle")
            .attr("transform", `rotate(-90, ${borderPadding / 2 - 10}, ${ch})`)
            .style("font-family", theme.borderFontFamily)
            .style("font-size", theme.borderFontSize)
            .style("font-weight", theme.borderFontWeight)
            .style("fill", theme.borderFontColor);
    }

    function updateGradientBoxes(svg, w, h, borderPadding, theme) {
        const cw = w / 2;
        const ch = h / 2;

        // Update clientsGradient (top gradient) - mainly adjusting width
        svg.select("rect#clientsGradientRect")
            .attr("width", w); // Stretch across the new width

        // Update text position if necessary
        svg.select("text#clientsText")
            .attr("x", w / 2); // Center text

        // Update serversGradient (bottom gradient) - adjust Y position and width
        svg.select("rect#serversGradientRect")
            .attr("y", h - borderPadding / 2) // Move to the new bottom position
            .attr("width", w) // Stretch across the new width
            .attr("fill", "red");

        // Update text position if necessary
        svg.select("text#serversText")
            .attr("x", w / 2) // Center text
            .attr("y", h - borderPadding / 2 + 16); // Adjust based on your specific offset

        // Update publicGradient (right gradient) - adjust X position and height
        svg.select("rect#publicGradientRect")
            .attr("x", w - borderPadding / 2) // Move to the new right side position
            .attr("height", h); // Stretch across the new height

        // Update text position if necessary, adjusting for rotation
        svg.select("text#publicText")
            .attr("x", w - (borderPadding / 2))
            .attr("y", ch - 10)
            .attr("transform", `rotate(90, ${w - (borderPadding / 2)}, ${ch})`);

        // Update privateGradient (left gradient) - height adjustment only as it's already at x = 0
        svg.select("rect#privateGradientRect")
            .attr("height", h); // Stretch across the new height

        // Update text position if necessary, adjusting for rotation
        svg.select("text#privateText")
            .attr("x", borderPadding / 2)
            .attr("y", ch)
            .attr("transform", `rotate(-90, ${borderPadding / 2 - 10}, ${ch})`);

    }

    let positionsMap = new Map();
    function saveCurrentPositions(svg) {
        svg.selectAll('.app').each(function(d) {
            if (d) {
                positionsMap.set(d.name, { x: d.x, y: d.y });
            }
        });
    }

    function updateApps(svg, data, w, h, borderPadding, theme) {
        const cw = w / 2;
        const ch = h / 2;

        saveCurrentPositions(svg);
        //svg.selectAll('.app').remove();

        const pieColors = d3.scaleOrdinal()
            .domain(["public", "private", "listenInbound", "outbound", "others"])
            .range([theme.publicColor, theme.privateColor, theme.serverColor, theme.clientColor, theme.otherColor]);

        const pie = d3.pie().value(d => d.value);
        const arc = d3.arc();

        // Binding data with key function
        const app = svg.selectAll('.app')
            .data(data, d => d.name);

        // Remove any elements that no longer have data associated with them
        app.exit()
            .transition()
            .style("opacity", 0)
            .remove();

        // Enter selection for new data points
        const appEnter = app.enter()
            .append('g')
            .attr('class', 'app')
            .attr('transform', `translate(${cw}, ${ch})`); // Start from center

        // Initialize new elements
        appEnter.each(function (d) {
            const group = d3.select(this);
            const pieData = pie(d.d3.pie);

            group.selectAll('path')
                .data(pieData)
                .enter().append('path')
                .transition()
                .attr('fill', (d, i) => pieColors(i));

            group.append('text')
                .text(d => d.name)
                .attr('text-anchor', 'middle')
                .style('font-family', theme.appFontFamily)
                .style('font-size', theme.appFontSize)
                .style('font-weight', theme.appFontWeight)
                .style('fill', theme.appFontColor);
        });

        // Transition for new elements
        appEnter.transition()
            .attr('transform', d => `translate(${d.d3.x}, ${d.d3.y})`);

        // Merge the enter and update selections
        const mergedApp = appEnter.merge(app);

        // Update saved positions
        mergedApp.each(function (d) {
            const group = d3.select(this);
            const oldPos = positionsMap.get(d.name) || { x: cw, y: ch };

            d.x = oldPos.x;
            d.y = oldPos.y;

            group.selectAll('path')
                .data(pie(d.d3.pie))
                .transition()
                .attr('d', arc.innerRadius(0).outerRadius(d.d3.size));

            group.select('text')
                .transition()
                .attr('y', d.d3.size + 10);
        });

        // Apply the drag behavior to the merged selection
        mergedApp.call(d3.drag()
            .on('start', dragstarted)
            .on('drag', dragged)
            .on('end', dragended));

        return mergedApp;
    }

    let forceInit = true;
    let initial = true;
    let simulation;

    function dragstarted(event, d) {
        if (!event.active) simulation.alphaTarget(1).restart();
        d.fx = d.x;
        d.fy = d.y;
    }

    function dragged(event, d) {
        d.fx = event.x;
        d.fy = event.y;
    }

    function dragended(event, d) {
        if (!event.active) simulation.alphaTarget(0);
        d.fx = null;
        d.fy = null;
    }

    // Function to draw the circles and labels for each application with border forces
    function drawApplications(data, w, h, borderPadding, theme) {
        let svg = d3.select('#d3-canvas').select('svg');

        if(svg.empty()) {
            svg = d3.select('#d3-canvas').select('svg');
            svg = d3.select('#d3-canvas').append('svg')
                .attr('width', '100%')
                .attr('height', '100%');

            drawInitialChart(svg, data, w, h, borderPadding, theme);
            forceInit = false;
        }

        updateGradientBoxes(svg, w, h, borderPadding, theme);

        const app = updateApps(svg, data, w, h, borderPadding, theme);

        app.transition().duration(5000)

        simulation = d3.forceSimulation(data)
            //.force('center', d3.forceCenter(cw, ch).strength(1))
            .force("x", d3.forceX(d => d.d3.x).strength(d => {
                if(d.counts.listen === d.counts.total)
                    return 0.5
                else
                    return 0.05
            }))
            .force("y", d3.forceY(d => d.d3.y).strength(d => {
                if(d.counts.listen === d.counts.total)
                    return 0.5
                else
                    return 0.05
            }))
            //.force("charge", d3.forceManyBody().strength(-0.05))
            .force("collide", d3.forceCollide(d => d.d3.size * 1.1 + 15).strength(1))
            .on('tick', ticked);

        function ticked() {
            data.forEach(d => {
                if(d.x > w - d.d3.size)
                    d.x = w - d.d3.size;
                else if(d.x < 0)
                    d.x = 0;

                if(d.y > h - d.d3.size)
                    d.y = h - d.d3.size;
                else if(d.y < 0)
                    d.y = 0;
            });

            app.attr('transform', d => `translate(${d.x}, ${d.y})`);
        }

        initial = false;
    }

    let lastPayload = {};
    function redrawChart() {
        const transformed = transformData(lastPayload);
        console.log(transformed);

        const w = window.innerWidth;
        const h = window.innerHeight;
        const borderPadding = 40;

        const normalized = normalizeData(transformed, w, h, borderPadding);
        drawApplications(normalized, w, h, borderPadding, themes.dark);

        // Update SVG dimensions
        const svg = d3.select('#d3-canvas').select('svg')
            .attr('width', w)
            .attr('height', h);
    }

    // Debounce function to optimize performance
    function debounce(func, timeout = 50) {
        let timer;
        return (...args) => {
            clearTimeout(timer);
            timer = setTimeout(() => { func.apply(this, args); }, timeout);
        };
    }

    // Attach the event listener to the window resize event
    window.addEventListener('resize', debounce(() => {
        forceInit = true;
        redrawChart();
    }));

    // Modify your fetchData function to call drawApplications after data transformation
    function fetchDataAndUpdateChart() {
        fetch('http://localhost:19999/api/v1/function?function=network-connections')
            .then(response => response.json())
            .then(data => {
                lastPayload = data;
                redrawChart();
            })
            .catch(error => console.error('Error fetching data:', error));
    }

    // Initial load
    window.onload = () => {
        fetchDataAndUpdateChart();
        setInterval(fetchDataAndUpdateChart, 2000); // You may need to adjust this part
    };
</script>
</body>
</html>
